[Миграция] Миграция проекта myblog на другой сервер.

Обновите систему и установите Python и необходимые инструменты:

sudo apt update
sudo apt upgrade -y
sudo apt install python3 python3-pip python3-venv -y

Проверьте версию Python:

python3 --version

2. Создание проекта и копирование файлов

Создайте директорию для проекта:

mkdir -p /root/Myblog

Скопируйте содержимое проекта со старого сервера с помощью rsync (команду выполняйте на старом сервере):

rsync -avz /root/Myblog/ user@server_ip:/root/Myblog/

Если rsync нет, воспользуйтесь scp:

scp -r /root/Myblog user@server_ip:/root/

3. Создание виртуального окружения и установка зависимостей

Перейдите в каталог проекта:

cd /root/Myblog

Создайте виртуальное окружение:

python3 -m venv venv

Активируйте его:

source venv/bin/activate

Установи необходимые библиотеки

pip install fastapi uvicorn jinja2 python-multipart aiofiles pillow markdown

Сгенерируй новый секретный ключ:

python3 -c "import secrets; print(secrets.token_urlsafe(32))"

Скопируй полученную строку.

Создаём файл для хранения паролей

В папке

 cd /root/Myblog

Создаем файл .env:

nano .env

Вписиваеи туда:

ADMIN_LOGIN=tonicman
ADMIN_PASSWORD=твой_пароль
SESSION_SECRET_KEY=твой_секретный_код

Сохрани изменения (Ctrl+O, Enter) и выйди (Ctrl+X).

Запусти приложение вручную для проверки

uvicorn main:app --host 0.0.0.0 --port 8081

Или явно через виртуальное окружение:

./venv/bin/uvicorn main:app --host 0.0.0.0 --port 8081

Проверь в браузере, что доступно по адресу: http://IP_сервера:8081

Настрой автозапуск через systemd (опционально)

Создай файл службы:

sudo nano /etc/systemd/system/myblog.service

Вставь туда:

[Unit]
Description=MyBlog FastAPI Application
After=network.target

[Service]
User=root
WorkingDirectory=/root/Myblog
ExecStart=/root/Myblog/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8081
Restart=always

[Install]
WantedBy=multi-user.target

Или вариант с TLS сертификатом:

[Unit]
Description=MyBlog FastAPI Service
After=network.target

[Service]
User=root
WorkingDirectory=/root/Myblog
Environment="PATH=/root/Myblog/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/bin"
ExecStart=/root/Myblog/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8081 --ssl-certfile /etc/letsencrypt/live/server.tonicman.ru/fullchain.pem --ssl-keyfile /etc/letsencrypt/live/server.tonicman.ru/privkey.pem
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Если настроена маскировка (Fallback) для 3X-UI + Nginx:

[Unit]
Description=MyBlog FastAPI Service
After=network.target

[Service]
User=root
WorkingDirectory=/root/Myblog
Environment="PATH=/root/Myblog/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/bin"
ExecStart=/root/Myblog/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8081
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Сохрани (Ctrl+O) и выйди (Ctrl+X).

sudo systemctl daemon-reload
sudo systemctl enable myblog
sudo systemctl start myblog

Проверь статус службы:

sudo systemctl status myblog

4. Код проекта Myblog

main.py:

import os
from fastapi import FastAPI, Request, Form, HTTPException, File, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from pathlib import Path
import markdown
import json
import logging
import shutil
import re
from typing import List

app = FastAPI()

# Middleware для корректной работы в подпапке (через X-Forwarded-Prefix от Nginx)
@app.middleware("http")
async def add_proxy_headers(request: Request, call_next):
    path_prefix = request.headers.get("X-Forwarded-Prefix")
    if path_prefix:
        request.scope["root_path"] = path_prefix
    return await call_next(request)

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)

BASE_DIR = Path(__file__).parent.resolve()

# Функция для загрузки .env без сторонних библиотек
def load_env_file(dotenv_path: Path):
    if dotenv_path.exists():
        for line in dotenv_path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                key, value = line.split("=", 1)
                os.environ[key.strip()] = value.strip()

# Загружаем переменные из .env
load_env_file(BASE_DIR / ".env")

# Берем данные из окружения
ADMIN_LOGIN = os.getenv("ADMIN_LOGIN", "tonicman")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "default_pass")
SESSION_SECRET_KEY = os.getenv("SESSION_SECRET_KEY", "default_secret_key_change_me")

STATIC_DIR = BASE_DIR / "static"
GALLERY_DIR = STATIC_DIR / "gallery"
POSTS_DIR = BASE_DIR / "posts"
TEMPLATE_DIR = BASE_DIR / "templates"
PROMPTS_FILE = GALLERY_DIR / "prompts.json"

templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")

app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY)

def load_prompts() -> dict:
    if PROMPTS_FILE.exists():
        try:
            content = PROMPTS_FILE.read_text(encoding="utf-8")
            data = json.loads(content)
            if isinstance(data, dict):
                return data
        except Exception as e:
            logger.error(f"Ошибка загрузки prompts.json: {e}")
    return {}

def save_prompts(prompts: dict):
    try:
        content = json.dumps(prompts, ensure_ascii=False, indent=2)
        PROMPTS_FILE.write_text(content, encoding="utf-8")
    except Exception as e:
        logger.error(f"Ошибка сохранения prompts.json: {e}")
        raise

def require_auth(request: Request):
    if not request.session.get("authenticated"):
        raise HTTPException(status_code=401, detail="Требуется авторизация")

def insert_zero_width_spaces(text: str) -> str:
    def replacer(match):
        full_match = match.group(0)
        if full_match.startswith('<'):
            return full_match
        word = full_match
        return '\u200b'.join(word[i:i+10] for i in range(0, len(word), 10))
    return re.sub(r'(<[^>]+>)|(\S{11,})', replacer, text)

templates.env.filters['insert_zwsp'] = insert_zero_width_spaces

@app.get("/", include_in_schema=False)
async def root():
    return RedirectResponse(url="main")

@app.get("/login", response_class=HTMLResponse)
async def login_form(request: Request):
    # Запоминаем, откуда пришёл пользователь
    referer = request.headers.get("Referer", "main")
    return templates.TemplateResponse("login.html", {"request": request, "error": False, "next_url": referer})

@app.get("/logout")
async def logout(request: Request):
    request.session.clear()
    # Получаем адрес страницы, с которой нажали "Выйти"
    referer = request.headers.get("Referer", "main") 
    return RedirectResponse(url=referer)


@app.post("/login", response_class=HTMLResponse)
async def login(request: Request, username: str = Form(...), password: str = Form(...), next_url: str = Form("main")):
    if username == ADMIN_LOGIN and password == ADMIN_PASSWORD:
        request.session["authenticated"] = True

        # Проверяем, куда нужно вернуть пользователя
        if "gallery" in next_url:
            return RedirectResponse(url="gallery", status_code=303)
        elif "projects" in next_url:
            return RedirectResponse(url="projects", status_code=303)

        return RedirectResponse(url="main", status_code=303)
    return templates.TemplateResponse("login.html", {"request": request, "error": True, "next_url": next_url})

@app.get("/main", response_class=HTMLResponse)
async def main_page(request: Request):
    if POSTS_DIR.exists():
        posts = sorted(
            [f for f in POSTS_DIR.glob("*.md")],
            key=lambda x: x.stat().st_mtime,
            reverse=True
        )
        post_names = [f.stem for f in posts]
    else:
        post_names = []
    return templates.TemplateResponse("main.html", {"request": request, "posts": post_names, "is_auth": request.session.get("authenticated", False)})

@app.get("/article/{name}", response_class=HTMLResponse)
async def article_page(request: Request, name: str):
    md_path = POSTS_DIR / f"{name}.md"
    if not md_path.exists():
        raise HTTPException(status_code=404, detail="Статья не найдена")
    text = md_path.read_text(encoding="utf-8")
    html = markdown.markdown(text, extensions=['fenced_code', 'tables', 'codehilite'])
    return templates.TemplateResponse("article.html", {"request": request, "title": name, "content": html, "is_auth": request.session.get("authenticated", False)})

@app.post("/article/delete/{name}")
async def delete_article(name: str, request: Request):
    require_auth(request)
    path = POSTS_DIR / f"{name}.md"
    if path.exists(): 
        path.unlink()

    # ПРОВЕРКА: Если это AJAX запрос (через fetch)
    if request.headers.get("X-Requested-With") == "XMLHttpRequest":
        return JSONResponse({"status": "ok"})

    # Если это обычная отправка формы (из статьи)
    root_path = request.scope.get("root_path", "")
    return RedirectResponse(url=f"{root_path}/main", status_code=303)

@app.get("/gallery", response_class=HTMLResponse)
async def gallery(request: Request):
    items = []
    prompts = load_prompts()
    if GALLERY_DIR.exists():
        files = sorted([f for f in GALLERY_DIR.iterdir() if f.is_file()], key=lambda x: x.stat().st_mtime, reverse=True)
        for f in files:
            if f.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".mp4"}:
                # Путь к статике сделан относительным (без ведущего слэша)
                items.append({"src": f"static/gallery/{f.name}", "name": f.name, "prompt": prompts.get(f.name, ""), "is_video": f.suffix.lower() == ".mp4"})
    return templates.TemplateResponse("gallery.html", {"request": request, "items": items, "is_auth": request.session.get("authenticated", False)})

@app.post("/gallery/prompt_save")
async def save_prompt(name: str = Form(...), prompt: str = Form(""), request: Request = None):
    require_auth(request)
    prompts = load_prompts()
    prompts[name] = prompt
    save_prompts(prompts)
    return JSONResponse({"status": "ok"})

@app.get("/upload_gallery", response_class=HTMLResponse)
async def upload_gallery_page(request: Request):
    if not request.session.get("authenticated"): return RedirectResponse(url="login")
    return templates.TemplateResponse("upload_gallery.html", {"request": request, "is_auth": True})

@app.get("/projects", response_class=HTMLResponse)
async def projects_page(request: Request):
    return templates.TemplateResponse("projects.html", {
        "request": request,
        "is_auth": request.session.get("authenticated", False)
    })
@app.post("/upload_gallery")
async def upload_gallery_files(request: Request, files: List[UploadFile] = File(...)):
    require_auth(request)
    results = []
    # Допустимые типы и расширения
    allowed_extensions = {".jpg", ".jpeg", ".png", ".gif", ".mp4"}

    for file in files:
        file_ext = Path(file.filename).suffix.lower()
        # Проверяем либо через content_type, либо просто по расширению
        is_valid_type = (
            file.content_type in {"image/jpeg", "image/png", "image/gif", "video/mp4"} or 
            file_ext in allowed_extensions
        )

        if is_valid_type:
            save_path = GALLERY_DIR / file.filename
            with save_path.open("wb") as buffer: 
                shutil.copyfileobj(file.file, buffer)
            results.append({"filename": file.filename, "status": "ok"})
        else:
            logger.warning(f"Отказ в загрузке: {file.filename} (тип: {file.content_type})")

    return JSONResponse({"results": results})

@app.post("/gallery/delete/{name}")
async def delete_gallery_image(name: str, request: Request):
    require_auth(request)
    path = GALLERY_DIR / name
    if path.exists(): 
        path.unlink()

    prompts = load_prompts()
    if name in prompts:
        del prompts[name]
        save_prompts(prompts)

    # Вместо редиректа возвращаем успех
    return JSONResponse({"status": "deleted", "name": name})

@app.post("/gallery/delete_all")
async def delete_all_gallery(request: Request):
    require_auth(request)
    # Расширения, которые мы считаем контентом галереи
    allowed_extensions = {".jpg", ".jpeg", ".png", ".gif", ".mp4"}

    if GALLERY_DIR.exists():
        for file in GALLERY_DIR.iterdir():
            # Удаляем только файлы изображений и видео, не трогая саму папку
            if file.is_file() and file.suffix.lower() in allowed_extensions:
                try:
                    file.unlink()
                except Exception as e:
                    logger.error(f"Ошибка при удалении файла галереи {file}: {e}")

        # Очищаем базу промтов, записывая в неё пустой словарь
        save_prompts({})

    root_path = request.scope.get("root_path", "")
    return RedirectResponse(url=f"{root_path}/gallery", status_code=303)

@app.get("/upload_article", response_class=HTMLResponse)
async def upload_article_page(request: Request):
    if not request.session.get("authenticated"): 
        return RedirectResponse(url="login")
    return templates.TemplateResponse("upload_article.html", {"request": request, "is_auth": True})

@app.post("/upload_article")
async def upload_article_post(request: Request, files: List[UploadFile] = File(...)):
    require_auth(request)
    results = []
    for file in files:
        if file.filename.lower().endswith(".md"):
            # Очистка имени файла
            safe_name = re.sub(r'[^\w\s\-\.\[\]]', '', file.filename).strip()
            POSTS_DIR.mkdir(exist_ok=True)
            content = (await file.read()).decode('utf-8')
            (POSTS_DIR / safe_name).write_text(content, encoding='utf-8')
            results.append({"filename": safe_name, "status": "ok"})
    return JSONResponse({"results": results})

@app.post("/articles/delete_all")
async def delete_all_articles(request: Request):
    require_auth(request)
    # Проверяем существование папки и удаляем все .md файлы
    if POSTS_DIR.exists():
        for file in POSTS_DIR.glob("*.md"):
            try:
                file.unlink()
            except Exception as e:
                logger.error(f"Ошибка при удалении файла {file}: {e}")

    # ПРОВЕРКА: Если это AJAX запрос
    if request.headers.get("X-Requested-With") == "XMLHttpRequest":
        return JSONResponse({"status": "ok"})

    # Если это обычный переход/отправка формы
    root_path = request.scope.get("root_path", "")
    return RedirectResponse(url=f"{root_path}/main", status_code=303)

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app", host="0.0.0.0", port=8081)

main.html:

<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
  <title>Мой Блог</title>
  <link rel="icon" href="../static/favicon.ico" type="image/x-icon" />
  <style>
    :root {
      --bg-dark: #0a0a0a; 
      --text-dark: #d1d5db; 
      --card-dark: #121212;
      --accent: #2196f3; 
      --transition: 0.3s;
    }

    * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; font-family: 'Tahoma', sans-serif !important; }

    body {
      margin: 0; padding: 0; background: var(--bg-dark); color: var(--text-dark);
      display: flex; flex-direction: column; align-items: center; padding-bottom: 5rem;
      -webkit-font-smoothing: antialiased;
    }

    nav {
      width: 100%; display: flex; justify-content: center; align-items: center;
      gap: 12px; padding: 35px 20px 25px; flex-wrap: wrap; 
    }
    nav a {
      color: inherit; text-decoration: none; font-weight: bold; font-size: 0.95rem;
      opacity: 0.6; transition: var(--transition); padding: 8px 14px; border-radius: 10px;
    }
    nav a:hover, nav a.active { opacity: 1; color: var(--accent); background: rgba(33, 150, 243, 0.1); }

    .upload-btn { 
      color: var(--accent) !important; opacity: 1 !important; 
      border: 1px solid rgba(33, 150, 243, 0.2) !important;
      background: rgba(33, 150, 243, 0.05) !important;
    }

    h1 {
      font-size: clamp(28px, 8vw, 56px); font-weight: bold; color: var(--accent); 
      margin: 20px 0 10px; text-transform: uppercase; letter-spacing: 1.5px;
    }

    .count-badge { font-size: 1.2rem; opacity: 0.3; vertical-align: middle; margin-left: 12px; font-weight: normal; }

    .controls { width: 90%; max-width: 800px; display: flex; flex-direction: column; align-items: center; gap: 25px; margin-bottom: 50px; }

    #searchInput {
      width: 100%; max-width: 550px; padding: 16px 24px; border-radius: 14px; border: 1px solid #222;
      background: var(--card-dark); color: white; font-size: 1rem; outline: none; transition: 0.3s;
    }
    #searchInput:focus { border-color: var(--accent); box-shadow: 0 0 20px rgba(33, 150, 243, 0.15); }

    .categories-tags { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
    .tag {
      padding: 7px 16px; border-radius: 20px; background: #1a1a1a;
      font-size: 0.85rem; font-weight: bold; cursor: pointer; transition: 0.2s;
      border: 1px solid #222; color: #888;
    }
    .tag:hover { color: #fff; border-color: var(--accent); }
    .tag.active { background: var(--accent); color: white; border-color: var(--accent); }

    .container { width: 95%; max-width: 1200px; }

    ul {
      display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
      gap: 20px; padding: 0; list-style: none;
    }
    li {
      background: var(--card-dark); border-radius: 14px; border: 1px solid #1a1a1a;
      transition: all var(--transition); position: relative; display: flex; align-items: center;
      overflow: hidden;
    }
    li:hover {
      border-color: #333; transform: translateY(-4px);
      box-shadow: 0 15px 30px rgba(0,0,0,0.4);
    }

    .post-icon {
      margin-left: 20px; width: 18px; height: 22px;
      border: 2px solid var(--accent); border-radius: 3px;
      position: relative; flex-shrink: 0; opacity: 0.5;
    }
    .post-icon::after {
      content: ''; position: absolute; top: -2px; right: -2px; width: 6px; height: 6px;
      background: var(--card-dark); border-left: 2px solid var(--accent); border-bottom: 2px solid var(--accent); border-bottom-left-radius: 2px;
    }
    .post-icon::before {
      content: ''; position: absolute; top: 8px; left: 3px; width: 8px; height: 1.5px;
      background: var(--accent); box-shadow: 0 4px 0 var(--accent), 0 8px 0 var(--accent);
    }

    li a {
      display: block; padding: 25px 50px 25px 15px; color: #cbd5e1;
      text-decoration: none; font-size: 1.1rem; font-weight: bold; width: 100%; 
      line-height: 1.4; white-space: normal; word-wrap: break-word;
    }
    li:hover a { color: #fff; }

    .btn-delete {
      position: absolute; right: 15px; top: 50%; transform: translateY(-50%);
      background: transparent; color: #ef4444; border: none; width: 30px; height: 30px; border-radius: 8px; 
      cursor: pointer; opacity: 0; transition: 0.2s;
      display: flex; align-items: center; justify-content: center; font-size: 1.5rem;
    }
    li:hover .btn-delete { opacity: 0.6; }
    .btn-delete:hover { opacity: 1 !important; background: rgba(239, 68, 68, 0.1); }

    #delete-all {
      background: transparent; color: #555; border: 1px solid #222; 
      padding: 10px 20px; border-radius: 10px; cursor: pointer; 
      margin: 10px 0 30px 5px; font-size: 0.85rem; font-weight: bold; transition: 0.2s;
    }
    #delete-all:hover { color: #ef4444; border-color: #ef4444; }

    @media (max-width: 600px) {
      nav { 
        padding: 25px 15px 15px; 
        gap: 8px; 
        display: flex;
        flex-wrap: nowrap !important;
        justify-content: flex-start !important; 
        overflow-x: auto; 
        width: 100vw;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: none; 
      }
      nav::-webkit-scrollbar { display: none; }
      nav a { padding: 8px 14px; font-size: 14px; flex-shrink: 0 !important; display: inline-block; }

      h1 { font-size: 32px; margin-top: 10px; }
      .controls { width: 100%; padding: 0 10px; margin-bottom: 30px; }
      ul { grid-template-columns: 1fr; gap: 12px; padding: 0 10px; }
      li a { padding: 20px 45px 20px 12px; font-size: 1rem; }

      /* Заменяем старую строку на это: */
      .btn-delete { 
        opacity: 0 !important; 
      }
      li:active .btn-delete { 
        opacity: 1 !important; 
      }
    }
  </style>
</head>
<body>

  <nav>
    <a href="main" class="active">Блог</a>
    <a href="gallery">Галерея</a>
    <a href="projects">Проекты</a>
    {% if is_auth %}
      <a href="upload_article" class="upload-btn">+ Добавить</a>
      <a href="logout">Выйти</a>
    {% else %}
      <a href="login">Войти</a>
    {% endif %}
  </nav>

  <h1>Статьи<span id="articles-count" class="count-badge">{{ posts|length }}</span></h1>

  <div class="controls">
    <input type="text" id="searchInput" placeholder="Поиск по названию..." autocomplete="off">
    <div class="categories-tags" id="categoryBox">
      <div class="tag active" data-cat="all">Все</div>
    </div>
  </div>

  <div class="container">
  {% if posts|length > 0 and is_auth %}
    <button id="delete-all" onclick="if(confirm('Очистить всё?')) { document.getElementById('delete-all-form').submit(); }">
      Очистить список
    </button>
    <form id="delete-all-form" method="POST" action="articles/delete_all"></form>
  {% endif %}

    <ul id="postsList">
      {% for post in posts %}
        <li data-name="{{ post }}" id="post-item-{{ loop.index }}">
          <div class="post-icon"></div>
          <a href="article/{{ post }}" title="{{ post }}">{{ post }}</a>
          {% if is_auth %}
            <button type="button" class="btn-delete" title="Удалить" 
                    onclick="ajaxDelete('{{ post }}', 'post-item-{{ loop.index }}')">&times;</button>
          {% endif %}
        </li>
      {% endfor %}
    </ul>
  </div>

<script>
  const searchInput = document.getElementById('searchInput');
  const categoryBox = document.getElementById('categoryBox');
  const postsList = document.getElementById('postsList');
  const countDisplay = document.getElementById('articles-count');
  let currentCategory = 'all';

  // Функция для получения актуального списка элементов
  const getItems = () => postsList.querySelectorAll('li');

  async function ajaxDelete(name, elementId) {
    if (!confirm(`Удалить статью "${name}"?`)) return;
    try {
        const response = await fetch(`article/delete/${name}`, {
            method: 'POST',
            headers: { 'X-Requested-With': 'XMLHttpRequest' }
        });
        if (response.ok) {
            const el = document.getElementById(elementId);
            if (el) {
                el.remove();
                filterPosts(); 
            }
        }
    } catch (err) { console.error(err); }
  }

  // Первичная обработка категорий
  const initCategories = () => {
    const categories = new Set();
    getItems().forEach(item => {
      const name = item.dataset.name;
      const match = name.match(/^\[(.*?)\]/);
      if (match) {
        categories.add(match[1]);
        item.dataset.category = match[1].toLowerCase();
      } else {
        item.dataset.category = 'none';
      }
    });

    categories.forEach(cat => {
      const div = document.createElement('div');
      div.className = 'tag';
      div.textContent = cat;
      div.dataset.cat = cat.toLowerCase();
      categoryBox.appendChild(div);
    });
  };

  function filterPosts() {
    const query = searchInput.value.toLowerCase().trim();
    let visibleCount = 0;
    getItems().forEach(item => {
      const name = item.dataset.name.toLowerCase();
      const cat = item.dataset.category;
      const matchesSearch = name.includes(query);
      const matchesCat = (currentCategory === 'all' || cat === currentCategory);
      if (matchesSearch && matchesCat) {
        item.style.display = 'flex';
        visibleCount++;
      } else {
        item.style.display = 'none';
      }
    });
    countDisplay.textContent = visibleCount;
  }

  categoryBox.onclick = (e) => {
    if (e.target.classList.contains('tag')) {
      categoryBox.querySelectorAll('.tag').forEach(t => t.classList.remove('active'));
      e.target.classList.add('active');
      currentCategory = e.target.dataset.cat;
      filterPosts();
    }
  };

  searchInput.oninput = filterPosts;
  initCategories();
</script>
</body>
</html>

article.html:

<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
  <title>{{ title }} | Блог</title>
  <link rel="icon" href="../static/favicon.ico" type="image/x-icon" />
  <style>
    :root {
      --bg-dark: #0a0a0a; 
      --btn-blue: #2196f3; 
      --text: #cbd5e1; 
      --border: #222;
      --transition: 0.3s;
    }

    * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; font-family: 'Tahoma', sans-serif !important; }
    pre, code, pre *, code * { font-family: 'Consolas', 'Monaco', monospace !important; }

    body {
      background: var(--bg-dark); color: var(--text);
      margin: 0; padding: 0 0 60px 0; line-height: 1.75; 
      display: flex; flex-direction: column; align-items: center;
      -webkit-font-smoothing: antialiased;
    }

    /* Унифицированная навигация */
    nav {
      width: 100%; display: flex; justify-content: center; align-items: center;
      gap: 12px; padding: 35px 20px 25px; flex-wrap: wrap; 
    }
    nav a {
      color: inherit; text-decoration: none; font-weight: bold; font-size: 0.95rem;
      opacity: 0.6; transition: var(--transition); padding: 8px 14px; border-radius: 10px;
    }
    nav a:hover { opacity: 1; color: var(--btn-blue); background: rgba(33, 150, 243, 0.1); }

    .upload-btn { 
      color: var(--btn-blue) !important; opacity: 1 !important; 
      border: 1px solid rgba(33, 150, 243, 0.2) !important;
      background: rgba(33, 150, 243, 0.05) !important;
    }

    .content { max-width: 850px; width: 100%; margin: 0 auto; padding: 0 30px; }
    .article-header { border-bottom: 1px solid #1a1a1a; padding-bottom: 30px; margin-bottom: 45px; }

    h1 { font-size: clamp(26px, 7vw, 42px); color: var(--btn-blue); margin: 15px 0; line-height: 1.2; font-weight: bold; text-align: left; }

    h2 { 
      color: #fff; margin: 55px 0 25px; font-size: 1.5rem; font-weight: bold; 
      padding-left: 22px; line-height: 1.4; border-left: 5px solid var(--btn-blue);
    }

    h3 {
      color: #fff; margin: 40px 0 20px; font-size: 1.25rem; font-weight: bold;
      padding-left: 18px; line-height: 1.4; border-left: 3px solid #333;
    }

    p { margin-bottom: 1.8rem; font-size: 1.1rem; text-align: left; color: #cbd5e1; word-wrap: break-word; }

    /* Стили для ссылок внутри текста статьи */
    #articleBody a {
      color: var(--btn-blue);
      text-decoration: none;
      border-bottom: 1px solid rgba(33, 150, 243, 0.3);
      transition: var(--transition);
    }
    #articleBody a:hover {
      border-bottom-color: var(--btn-blue);
      background: rgba(33, 150, 243, 0.1);
      border-radius: 4px;
    }

    .table-wrapper { width: 100%; overflow-x: auto; margin: 35px 0; border-radius: 12px; border: 1px solid var(--border); }
    table { width: 100%; border-collapse: collapse; background: #0d0d0d; font-size: 0.95rem; min-width: 500px; }
    th { background: rgba(33, 150, 243, 0.1); color: var(--btn-blue); padding: 15px; text-align: left; border: 1px solid var(--border); }
    td { padding: 12px 15px; border: 1px solid var(--border); color: #94a3b8; }
    tr:nth-child(even) { background: #121212; }

    .code-block {
      background: #0d0d0d; border-radius: 14px; margin: 35px 0;
      position: relative; padding: 30px 20px 20px; border: 1px solid #252525;
      display: table; min-width: 320px; max-width: 100%;
    }
    pre { margin: 0; overflow-x: auto; font-size: 14px; color: #e2e8f0; padding-right: 45px; }

    .btn-copy {
      position: absolute; top: 12px; right: 12px; width: 36px; height: 36px;
      background: #161616; border: 1px solid #333; border-radius: 10px;
      cursor: pointer; display: flex; align-items: center; justify-content: center;
      color: #777; transition: 0.2s;
    }
    .btn-copy:hover { border-color: var(--btn-blue); color: #fff; }

    .category-label {
      display: inline-block; color: #888; background: #111; padding: 5px 12px;
      border-radius: 6px; font-size: 11px; font-weight: bold; margin-bottom: 15px;
      text-transform: uppercase; border: 1px solid #222; letter-spacing: 0.5px;
    }

    .btn-share {
      display: inline-flex; align-items: center; gap: 10px;
      background: rgba(33, 150, 243, 0.05); color: var(--btn-blue);
      border: 1px solid rgba(33, 150, 243, 0.2) !important; padding: 10px 22px;
      border-radius: 10px; font-weight: bold; font-size: 0.9rem;
      cursor: pointer; transition: 0.2s;
    }

    @media (max-width: 600px) {
      nav { 
        padding: 25px 15px 15px; 
        gap: 8px; 
        display: flex;
        flex-wrap: nowrap !important;
        justify-content: flex-start !important; 
        overflow-x: auto; 
        width: 100vw;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: none; 
      }
      nav::-webkit-scrollbar { display: none; }
      nav a { padding: 8px 14px; font-size: 14px; flex-shrink: 0 !important; display: inline-block; }

      .content { padding: 0 20px; }
      h1 { font-size: 28px; line-height: 1.3; margin-top: 10px; }
      h2 { font-size: 1.4rem; margin: 45px 0 20px; line-height: 1.3; padding-left: 15px; }
      p { font-size: 1.1rem; line-height: 1.85; margin-bottom: 2rem; letter-spacing: 0.2px; }

      .code-block { display: block; margin: 35px -20px; border-radius: 0; width: calc(100% + 40px); border-left: none; border-right: none; }
    }

    .footer-actions {
      max-width: 850px; width: 100%; margin: 60px auto; padding: 35px 25px;
      border-top: 1px solid #1a1a1a; display: flex; justify-content: space-between; align-items: center;
    }
    .nav-back { color: #555; text-decoration: none; font-weight: bold; font-size: 0.95rem; }
  </style>
</head>
<body>
  <nav>
    <a href="../main">Блог</a>
    <a href="../gallery">Галерея</a>
    <a href="../projects">Проекты</a>
    {% if is_auth %}
      <a href="../upload_article" class="upload-btn">+ Добавить</a>
      <a href="../logout">Выйти</a>
    {% else %}
      <a href="../login">Войти</a>
    {% endif %}
  </nav>

  <article class="content">
    <header class="article-header" id="headerWrapper">
      <h1 id="mainTitle">{{ title }}</h1>
      <button id="shareBtn" class="btn-share">
        <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92c0-1.61-1.31-2.92-2.92-2.92z"/></svg>
        <span>Поделиться</span>
      </button>
    </header>
    <section id="articleBody">{{ content | safe }}</section>
  </article>

  <div class="footer-actions">
    {% if is_auth %}
      <form method="POST" action="../article/delete/{{ title|urlencode }}" onsubmit="return confirm('Удалить статью?');">
        <button type="submit" style="background: #ef4444; color: white; border: none; border-radius: 10px; padding: 12px 22px; font-weight: bold; cursor: pointer;">Удалить</button>
      </form>
    {% endif %}
    <a href="../main" class="nav-back">&larr; Назад</a>
  </div>

  <script>
    // Оживляем ссылки из чистого текста
    const articleBody = document.getElementById('articleBody');
    const urlRegex = /(?<!["=])(https?:\/\/[^\s<]+)/g;
    articleBody.querySelectorAll('p, li, td').forEach(el => {
      el.innerHTML = el.innerHTML.replace(urlRegex, (url) => {
        return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
      });
    });

    document.querySelectorAll('#articleBody table').forEach(table => {
      const wrapper = document.createElement('div');
      wrapper.className = 'table-wrapper';
      table.parentNode.insertBefore(wrapper, table);
      wrapper.appendChild(table);
    });

    const titleEl = document.getElementById('mainTitle');
    const headerWrapper = document.getElementById('headerWrapper');
    if (titleEl) {
      const match = titleEl.innerText.match(/^\[(.*?)\]\s*(.*)$/);
      if (match) {
        const badge = document.createElement('span');
        badge.className = 'category-label';
        badge.innerText = match[1];
        headerWrapper.insertBefore(badge, titleEl);
        titleEl.innerText = match[2];
      }
    }

    document.getElementById('shareBtn').onclick = function() {
      const prettyUrl = decodeURIComponent(window.location.href).replace(/ /g, '%20');
      navigator.clipboard.writeText(prettyUrl).then(() => {
        const span = this.querySelector('span');
        span.innerText = 'Скопировано!';
        setTimeout(() => { span.innerText = 'Поделиться'; }, 1500);
      });
    };

    const iconCopy = '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14 c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>';
    const iconCheck = '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>';

    document.querySelectorAll('pre > code').forEach(code => {
      const wrapper = document.createElement('div');
      wrapper.className = 'code-block';
      const pre = code.parentNode;
      pre.parentNode.insertBefore(wrapper, pre);
      wrapper.appendChild(pre);
      const btn = document.createElement('button');
      btn.className = 'btn-copy';
      btn.innerHTML = iconCopy;
      btn.onclick = () => {
        navigator.clipboard.writeText(code.innerText.trim()).then(() => {
          btn.innerHTML = iconCheck;
          btn.style.color = '#2196f3';
          setTimeout(() => { btn.innerHTML = iconCopy; btn.style.color = '#777'; }, 2000);
        });
      };
      wrapper.appendChild(btn);
    });

    document.querySelectorAll('h2').forEach(h2 => {
        h2.innerHTML = h2.innerHTML.replace(/^[\s|]+/, '');
    });
  </script>
</body>
</html>

login.html:

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
    <title>Вход</title>
    <style>
        :root { 
            --bg: #0a0a0a; 
            --card: #121212; 
            --accent: #2196f3; 
            --text: #d1d5db; 
            --transition: 0.3s;
        }

        * { 
            box-sizing: border-box; 
            -webkit-tap-highlight-color: transparent; 
            font-family: 'Tahoma', sans-serif !important;
        }

        body { 
            background: var(--bg); color: var(--text); 
            margin: 0; display: flex; flex-direction: column; 
            align-items: center; min-height: 100vh;
            -webkit-font-smoothing: subpixel-antialiased;
        }

        /* Унифицированная навигация Flexy */
        nav {
            width: 100%; display: flex; justify-content: center; align-items: center;
            gap: 12px; padding: 35px 20px 25px; flex-wrap: wrap; 
        }
        nav a {
            color: inherit; text-decoration: none; font-weight: bold; font-size: 0.95rem;
            opacity: 0.6; transition: var(--transition); padding: 8px 14px; border-radius: 10px;
            white-space: nowrap;
        }
        nav a:hover { 
            opacity: 1; color: var(--accent); background: rgba(33, 150, 243, 0.1); 
        }

        @media (max-width: 600px) {
            nav { 
                padding: 25px 15px 15px; 
                gap: 8px; 
                display: flex;
                flex-wrap: nowrap !important;
                justify-content: flex-start !important; 
                overflow-x: auto; 
                width: 100vw;
                -webkit-overflow-scrolling: touch;
                scrollbar-width: none; 
            }
            nav::-webkit-scrollbar { display: none; }
            nav a { padding: 8px 14px; font-size: 14px; flex-shrink: 0 !important; display: inline-block; }
        }

        /* Карточка логина */
        .login-container {
            background: var(--card); padding: 40px; border-radius: 20px;
            border: 1px solid #222; width: 90%; max-width: 380px;
            margin-top: 30px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
            transition: var(--transition);
        }
        .login-container:hover { border-color: var(--accent); }

        h2 { 
            margin: 0 0 25px; color: #fff; text-align: center; 
            text-transform: uppercase; letter-spacing: 2px; font-size: 1.5rem;
        }

        form { display: flex; flex-direction: column; gap: 20px; }
        .input-group { display: flex; flex-direction: column; gap: 8px; }
        .input-group label { font-size: 0.85rem; opacity: 0.6; padding-left: 5px; }

        input {
            background: #000; border: 1px solid #333; padding: 12px 15px;
            border-radius: 10px; color: #fff; outline: none; transition: 0.3s;
            font-size: 16px;
        }
        input:focus { border-color: var(--accent); box-shadow: 0 0 10px rgba(33, 150, 243, 0.2); }

        button {
            background: var(--accent); color: white; border: none; padding: 14px;
            border-radius: 10px; font-weight: bold; cursor: pointer; transition: 0.3s;
            margin-top: 10px; font-size: 1rem; text-transform: uppercase; letter-spacing: 1px;
        }
        button:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(33, 150, 243, 0.3); }

        .error {
            color: #ef4444; background: rgba(239, 68, 68, 0.1); padding: 10px;
            border-radius: 8px; text-align: center; font-size: 0.9rem;
            margin-bottom: 20px; border: 1px solid rgba(239, 68, 68, 0.2);
        }

        @media (max-width: 600px) {
            .login-container { padding: 30px 20px; margin-top: 10px; }
        }
    </style>
</head>
<body>

<nav>
    <a href="main">Блог</a>
    <a href="gallery">Галерея</a>
    <a href="projects">Проекты</a>
</nav>

<div class="login-container">
    <h2>Вход</h2>

    {% if error %}
        <div class="error">Неверный логин или пароль</div>
    {% endif %}

    <form method="POST">
        <input type="hidden" name="next_url" value="{{ next_url }}">

        <div class="input-group">
            <label>Логин</label>
            <input type="text" name="username" required autocomplete="username">
        </div>

        <div class="input-group">
            <label>Пароль</label>
            <input type="password" name="password" required autocomplete="current-password">
        </div>

        <button type="submit">Войти в систему</button>
    </form>
</div>

</body>
</html>

gallery.html:

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
<title>Галерея Артов</title>
<style>
  :root { 
    --bg: #0a0a0a; 
    --card: #121212; 
    --btn: #2196f3; 
    --text: #d1d5db; 
    --transition: 0.3s; 
  }

  * { 
    box-sizing: border-box; 
    -webkit-tap-highlight-color: transparent; 
    font-family: 'Tahoma', sans-serif !important;
  }

  body { 
    background: var(--bg); 
    color: var(--text); 
    padding: 0 0 40px 0; 
    margin: 0; 
    -webkit-font-smoothing: subpixel-antialiased;
  }

  /* Навигация (Унифицированная) */
  nav {
    width: 100%; display: flex; justify-content: center; align-items: center;
    gap: 12px; padding: 35px 20px 25px; flex-wrap: wrap; 
  }
  nav a {
    color: inherit; text-decoration: none; font-weight: bold; font-size: 0.95rem;
    opacity: 0.6; transition: var(--transition); padding: 8px 14px; border-radius: 10px;
  }
  nav a:hover, nav a.active { 
    opacity: 1; 
    color: var(--btn); 
    background: rgba(33, 150, 243, 0.1); 
  }

  .upload-btn { 
    color: var(--btn) !important; 
    opacity: 1 !important; 
    border: 1px solid rgba(33, 150, 243, 0.2) !important;
    background: rgba(33, 150, 243, 0.05) !important;
  }

  h1 { 
    text-align: center; color: var(--btn); text-transform: uppercase; 
    letter-spacing: 2px; margin: 10px 0 30px 0; font-weight: bold; 
    font-size: clamp(1.5rem, 6vw, 2.5rem);
  }

  @media (max-width: 600px) {
    nav { 
      padding: 25px 15px 15px; 
      gap: 8px; 
      display: flex;
      flex-wrap: nowrap !important;
      justify-content: flex-start !important; 
      overflow-x: auto; 
      width: 100vw;
      -webkit-overflow-scrolling: touch;
      scrollbar-width: none; 
    }
    nav::-webkit-scrollbar { display: none; }
    nav a { padding: 8px 14px; font-size: 14px; flex-shrink: 0 !important; display: inline-block; }
  }

  /* Стили Галереи */
  .admin-controls {
    width: 100%; display: flex; justify-content: center; margin-bottom: 30px;
  }

  #delete-all-gal {
    background: transparent; color: #666; border: 1px solid #333; 
    padding: 8px 15px; border-radius: 8px; cursor: pointer; 
    transition: var(--transition); font-size: 0.85rem; font-weight: bold;
  }
  #delete-all-gal:hover { 
    color: #ef4444; border-color: #ef4444; background: rgba(239, 68, 68, 0.05);
  }

  .gallery { 
    position: relative; width: 95%; max-width: 1400px; 
    margin: 0 auto; transition: height 0.4s ease; 
  }

  .item {
    position: absolute; width: calc(33.333% - 20px); background: var(--card); border-radius: 16px; 
    border: 1px solid #222; overflow: hidden; 
    transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s, opacity 0.3s; 
  }

  @media (max-width: 1100px) { .item { width: calc(50% - 15px); } }
  @media (max-width: 700px) { 
    .item { width: 100%; position: relative !important; margin-bottom: 20px; transform: none !important; left: 0 !important; top: 0 !important; } 
    .gallery { width: 92%; }
  }

  .item:hover { border-color: var(--btn); box-shadow: 0 0 25px rgba(33, 150, 243, 0.15); }

  .img-container { width: 100%; overflow: hidden; position: relative; cursor: pointer; border-radius: 16px 16px 0 0; background: #000; }
  .img-container img, .img-container video { width: 100%; height: auto; display: block; transition: transform 0.6s ease; pointer-events: none; }
  .item:hover img, .item:hover video { transform: scale(1.08); }

  .delete-btn {
    position: absolute; top: 8px; right: 8px; background: none; border: none;
    color: rgba(255, 255, 255, 0.6); font-size: 28px; line-height: 1; cursor: pointer;
    z-index: 10; transition: 0.2s; padding: 5px; text-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
  }
  .delete-btn:hover { color: #ef4444; transform: scale(1.2); }

  .badge { 
    position: absolute; top: 12px; left: 12px; background: rgba(0,0,0,0.6); 
    padding: 4px 10px; border-radius: 6px; font-size: 0.65rem; font-weight: bold; 
    z-index: 2; border: 1px solid rgba(255,255,255,0.1); pointer-events: none; 
  }

  .controls { padding: 15px; display: flex; flex-direction: column; gap: 10px; background: var(--card); }

  .prompt-area { 
    width: 100%; background: #000; border-radius: 8px; border: 1px solid #333; 
    padding: 10px; color: #aaa; resize: none; min-height: 80px; display: none; 
    font-family: 'Consolas', monospace !important; font-size: 0.85rem; box-sizing: border-box;
    user-select: text !important; -webkit-user-select: text !important;
  }

  button { padding: 10px; border: none; color: white; font-weight: bold; border-radius: 8px; cursor: pointer; transition: 0.2s; font-size: 0.85rem; }
  .toggle-prompt-btn { background: #2a2a2a; border: 1px solid #333; }
  .toggle-prompt-btn:hover { background: #333; }
  .copy-btn { background: #134e15; display: none; }
  .save-btn { background: var(--btn); display: none; }
</style>
</head>
<body>

<nav>
  <a href="main">Блог</a>
  <a href="gallery" class="active">Галерея</a>
  <a href="projects">Проекты</a>
  {% if is_auth %}
    <a href="upload_gallery" class="upload-btn">+ Добавить</a>
    <a href="logout">Выйти</a>
  {% else %}
    <a href="login?next=gallery">Войти</a>
  {% endif %}
</nav>

<h1>Галерея Артов</h1>

{% if is_auth and items|length > 0 %}
  <div class="admin-controls">
    <button id="delete-all-gal" onclick="if(confirm('Внимание! Это удалит ВСЕ изображения и видео. Продолжить?')) { document.getElementById('delete-all-gal-form').submit(); }">
      Очистить галерею
    </button>
    <form id="delete-all-gal-form" method="POST" action="gallery/delete_all" style="display:none;"></form>
  </div>
{% endif %}

<div class="gallery" id="gallery">
  {% for item in items %}
    <div class="item" data-name="{{ item.name }}" id="card-{{ item.name }}">
      <div class="badge">{{ 'VIDEO' if item.is_video else 'IMAGE' }}</div>

      {% if is_auth %}
        <button type="button" class="delete-btn" onclick="deleteItem('{{ item.name }}')">&times;</button>
      {% endif %}

      <div class="img-container" data-src="{{ item.src }}" data-type="{{ 'video' if item.is_video else 'img' }}">
        {% if item.is_video %}
          <video muted autoplay loop playsinline><source src="{{ item.src }}" type="video/mp4" /></video>
        {% else %}
          <img src="{{ item.src }}" alt="Арт" />
        {% endif %}
      </div>

      <div class="controls">
        <button class="toggle-prompt-btn">Показать промт</button>
        <textarea class="prompt-area">{{ item.prompt | default('') }}</textarea>

        <div style="display: flex; gap: 8px;">
            <button class="copy-btn" style="flex:1;">Копировать</button>
            {% if is_auth %}
            <button class="save-btn" style="flex:1;">Сохранить</button>
            {% endif %}
        </div>
      </div>
    </div>
  {% endfor %}
</div>

<script>
  const gallery = document.getElementById('gallery');

  function resizeInstance() {
    const items = gallery.getElementsByClassName('item');
    if (!items.length) { gallery.style.height = "auto"; return; }
    if (window.innerWidth < 700) { gallery.style.height = "auto"; return; }
    const gap = 20, colCount = window.innerWidth > 1100 ? 3 : 2;
    const colHeights = new Array(colCount).fill(0);
    const itemWidth = (gallery.offsetWidth - (gap * (colCount - 1))) / colCount;

    Array.from(items).forEach((item) => {
      item.style.width = itemWidth + "px";
      const minH = Math.min(...colHeights), idx = colHeights.indexOf(minH);
      item.style.transform = `translate3d(${idx * (itemWidth + gap)}px, ${minH}px, 0)`;
      colHeights[idx] += item.offsetHeight + gap;
    });
    gallery.style.height = Math.max(...colHeights) + "px";
  }

  async function deleteItem(name) {
    if (!confirm('Удалить этот арт?')) return;
    try {
        const response = await fetch(`gallery/delete/${name}`, { method: 'POST' });
        if (response.ok) {
            const card = document.getElementById(`card-${name}`);
            if (card) {
                card.style.opacity = '0';
                setTimeout(() => {
                    card.remove();
                    resizeInstance();
                }, 300);
            }
        }
    } catch (e) {
        console.error(e);
    }
  }

  window.addEventListener('load', resizeInstance);
  window.addEventListener('resize', resizeInstance);

  gallery.addEventListener('click', async e => {
    const container = e.target.closest('.img-container');
    if(container) {
      const src = container.getAttribute('data-src'), type = container.getAttribute('data-type');
      const lb = document.createElement('div');
      Object.assign(lb.style, { position: "fixed", top: 0, left: 0, width: "100%", height: "100%", background: "rgba(0,0,0,0.95)", display: "flex", justifyContent: "center", alignItems: "center", cursor: "zoom-out", zIndex: 1000, backdropFilter: "blur(15px)" });
      let content = document.createElement(type === 'video' ? 'video' : 'img');
      content.src = src; if(type === 'video') { content.autoplay = content.loop = content.controls = true; }
      Object.assign(content.style, { maxHeight: "92vh", maxWidth: "92vw", borderRadius: "12px", pointerEvents: "auto" });
      lb.appendChild(content); document.body.appendChild(lb);
      lb.onclick = () => lb.remove();
      return;
    }

    const btn = e.target.closest('.toggle-prompt-btn');
    if(btn) {
      const item = btn.closest('.item');
      const area = item.querySelector('.prompt-area'), copy = item.querySelector('.copy-btn'), save = item.querySelector('.save-btn');
      const isVisible = area.style.display === 'block';
      area.style.display = isVisible ? 'none' : 'block';
      copy.style.display = isVisible ? 'none' : 'block';
      if(save) save.style.display = isVisible ? 'none' : 'block';
      btn.textContent = isVisible ? 'Показать промт' : 'Скрыть промт';
      setTimeout(resizeInstance, 100);
    }

    if(e.target.classList.contains('copy-btn')) {
      const text = e.target.closest('.item').querySelector('.prompt-area').value;
      await navigator.clipboard.writeText(text);
      e.target.textContent = 'Готово!';
      setTimeout(() => e.target.textContent = 'Копировать', 1500);
    }

    if(e.target.classList.contains('save-btn')) {
      const item = e.target.closest('.item');
      const name = item.getAttribute('data-name');
      const prompt = item.querySelector('.prompt-area').value;
      const formData = new FormData();
      formData.append('name', name);
      formData.append('prompt', prompt);

      const res = await fetch('gallery/prompt_save', {
        method: 'POST',
        body: formData
      });
      if(res.ok) {
        e.target.textContent = 'ОК!';
        e.target.style.background = '#134e15';
        setTimeout(() => { e.target.textContent = 'Сохранить'; e.target.style.background = ''; }, 1500);
      }
    }
  });
</script>
</body>
</html>

upload_gallery.html:

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
<title>Загрузка в галерею</title>
<style>
  :root { --bg: #0a0a0a; --card: #121212; --accent: #2196f3; --text: #d1d5db; --transition: 0.3s; }
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; font-family: 'Tahoma', sans-serif !important; }
  body { background: var(--bg); color: var(--text); padding: 0 0 40px 0; display: flex; flex-direction: column; align-items: center; min-height: 100vh; margin: 0; -webkit-font-smoothing: subpixel-antialiased; }

  nav { width: 100%; display: flex; justify-content: center; align-items: center; gap: 12px; padding: 35px 20px 25px; flex-wrap: wrap; }
  nav a { color: inherit; text-decoration: none; font-weight: bold; font-size: 0.95rem; opacity: 0.6; transition: var(--transition); padding: 8px 14px; border-radius: 10px; white-space: nowrap; }
  nav a:hover, nav a.active { opacity: 1; color: var(--accent); background: rgba(33, 150, 243, 0.1); }

  @media (max-width: 600px) {
    nav { padding: 25px 15px 15px; gap: 8px; display: flex; flex-wrap: nowrap !important; justify-content: flex-start !important; overflow-x: auto; width: 100vw; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
    nav a { padding: 8px 14px; font-size: 14px; flex-shrink: 0; }
  }

  h1 { font-size: clamp(1.8rem, 8vw, 3rem); font-weight: bold; color: var(--accent); margin: 10px 0 30px; text-transform: uppercase; letter-spacing: 2px; text-align: center; }

  #dropzone {
    width: 90%; max-width: 600px; height: 250px; border: 2px dashed #333; border-radius: 20px;
    display: flex; flex-direction: column; justify-content: center; align-items: center;
    color: #555; cursor: pointer; transition: var(--transition); text-align: center; background: var(--card); margin-bottom: 25px; gap: 15px; padding: 20px;
  }
  #dropzone.loading-state { opacity: 0.5; pointer-events: none; border-style: solid; }
  #dropzone:hover:not(.loading-state), #dropzone.dragover { border-color: var(--accent); background: rgba(33, 150, 243, 0.05); color: var(--accent); transform: scale(1.01); }

  .select-btn { background: var(--accent); border: none; color: white; padding: 16px 40px; border-radius: 12px; font-weight: bold; font-size: 1rem; cursor: pointer; transition: var(--transition); width: 90%; max-width: 300px; text-transform: uppercase; letter-spacing: 1px; }
  .select-btn:disabled { background: #222; color: #555; cursor: not-allowed; transform: none !important; box-shadow: none !important; }

  #progress-container { width: 90%; max-width: 300px; height: 4px; background: #1a1a1a; border-radius: 2px; margin: 15px 0; display: none; overflow: hidden; }
  #progress-bar { width: 0%; height: 100%; background: var(--accent); transition: width 0.2s; }

  #message { font-weight: bold; text-align: center; color: var(--accent); font-size: 0.95rem; min-height: 1.5em; }
  .nav-back { margin-top: 10px; color: #555; text-decoration: none; font-weight: bold; font-size: 0.9rem; transition: var(--transition); display: inline-flex; align-items: center; gap: 5px; }
</style>
</head>
<body>
<nav>
  <a href="main">Блог</a>
  <a href="gallery" class="active">Галерея</a>
  <a href="projects">Проекты</a>
  {% if is_auth %}<a href="logout">Выйти</a>{% else %}<a href="login">Войти</a>{% endif %}
</nav>

<h1>Загрузка в галерею</h1>

<div id="dropzone">
  <div style="font-size: 3.5rem; opacity: 0.8;">📷</div>
  <div id="drop-text">Перетащите медиа сюда<br><span style="font-size: 0.8rem; opacity: 0.6;">или кликните для выбора</span></div>
</div>

<input type="file" id="fileInput" multiple accept="image/*,video/mp4" style="display:none" />

<div style="display:flex; flex-direction:column; align-items:center; gap:10px; width:100%;">
  <button id="mainBtn" class="select-btn" onclick="document.getElementById('fileInput').click()">Выбрать файлы</button>
  <div id="progress-container"><div id="progress-bar"></div></div>
  <div id="message"></div>
  <a href="gallery" class="nav-back">← Назад в галерею</a>
</div>


<script>
  const dropzone = document.getElementById('dropzone');
  const fileInput = document.getElementById('fileInput');
  const message = document.getElementById('message');
  const progressBar = document.getElementById('progress-bar');
  const progressContainer = document.getElementById('progress-container');
  const mainBtn = document.getElementById('mainBtn');
  const dropText = document.getElementById('drop-text');

  const setStatus = (text) => { message.textContent = text; };

  dropzone.onclick = () => fileInput.click();
  dropzone.ondragover = e => { e.preventDefault(); dropzone.classList.add('dragover'); };
  dropzone.ondragleave = () => dropzone.classList.remove('dragover');
  dropzone.ondrop = e => { 
    e.preventDefault(); 
    dropzone.classList.remove('dragover'); 
    if(e.dataTransfer.files.length) upload(e.dataTransfer.files); 
  };
  fileInput.onchange = () => { if(fileInput.files.length) upload(fileInput.files); };

  function upload(files) {
    const formData = new FormData();
    for(const f of files) formData.append('files', f);

    const xhr = new XMLHttpRequest();
    xhr.open('POST', 'upload_gallery', true);

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        progressContainer.style.display = 'block';
        const percent = Math.round((e.loaded / e.total) * 100);
        progressBar.style.width = percent + '%';
        setStatus(`Загрузка: ${percent}%`);
      }
    };

    xhr.onload = () => {
      if (xhr.status === 200) {
        setStatus('✅ Успешно загружено!');
        setTimeout(() => window.location.href = 'gallery', 1000);
      } else {
        resetUI('❌ Ошибка сервера');
      }
    };

    xhr.onerror = () => resetUI('❌ Ошибка сети');

    // Блокируем UI для единообразия
    mainBtn.disabled = true;
    dropzone.classList.add('loading-state');
    dropText.innerHTML = 'Файлы обрабатываются...';

    xhr.send(formData);
  }

  function resetUI(msg) {
    setStatus(msg);
    mainBtn.disabled = false;
    dropzone.classList.remove('loading-state');
    progressContainer.style.display = 'none';
    progressBar.style.width = '0%';
    dropText.innerHTML = 'Перетащите медиа сюда<br><span style="font-size: 0.8rem; opacity: 0.6;">или кликните для выбора</span>';
  }
</script>
</body>
</html>  

</body>
</html>

upload_article.html:

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
<title>Загрузка статьи</title>
<style>
  :root { --bg: #0a0a0a; --card: #121212; --accent: #2196f3; --text: #d1d5db; --transition: 0.3s; }
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; font-family: 'Tahoma', sans-serif !important; }
  body { background: var(--bg); color: var(--text); padding: 0 0 40px 0; display: flex; flex-direction: column; align-items: center; min-height: 100vh; margin: 0; -webkit-font-smoothing: subpixel-antialiased; }

  nav { width: 100%; display: flex; justify-content: center; align-items: center; gap: 12px; padding: 35px 20px 25px; flex-wrap: wrap; }
  nav a { color: inherit; text-decoration: none; font-weight: bold; font-size: 0.95rem; opacity: 0.6; transition: var(--transition); padding: 8px 14px; border-radius: 10px; white-space: nowrap; }
  nav a:hover, nav a.active { opacity: 1; color: var(--accent); background: rgba(33, 150, 243, 0.1); }

  h1 { font-size: clamp(1.8rem, 8vw, 3rem); font-weight: bold; color: var(--accent); margin: 10px 0 30px; text-transform: uppercase; letter-spacing: 2px; text-align: center; }

  #dropzone {
    width: 90%; max-width: 600px; height: 220px; border: 2px dashed #333; border-radius: 20px;
    display: flex; flex-direction: column; justify-content: center; align-items: center;
    color: #555; font-weight: bold; cursor: pointer; transition: var(--transition); margin: 20px 0; background: var(--card); text-align: center; padding: 20px;
  }
  #dropzone.dragover { border-color: var(--accent); background: rgba(33, 150, 243, 0.05); color: var(--accent); transform: scale(1.02); }
  #dropzone.loading-ui { opacity: 0.5; pointer-events: none; }

  .action-btn { background: var(--accent); color: white; border: none; border-radius: 12px; padding: 16px 40px; font-size: 1rem; font-weight: bold; cursor: pointer; transition: var(--transition); width: 90%; max-width: 300px; text-transform: uppercase; letter-spacing: 1px; margin-top: 10px; }
  .action-btn:disabled { background: #222; color: #555; cursor: not-allowed; }

  #progress-wrap { width: 90%; max-width: 300px; height: 4px; background: #1a1a1a; border-radius: 2px; margin: 15px auto; display: none; overflow: hidden; }
  #progress-fill { width: 0%; height: 100%; background: var(--accent); transition: width 0.2s; }

  .message { margin-top: 10px; color: var(--accent); font-weight: bold; text-align: center; min-height: 1.5em; font-size: 0.95rem; }
  .nav-back { margin-top: 30px; color: #555; text-decoration: none; font-weight: bold; font-size: 0.9rem; transition: var(--transition); display: inline-flex; align-items: center; gap: 5px; }
</style>
</head>
<body>
<nav>
  <a href="main">Блог</a>
  <a href="gallery">Галерея</a>
  <a href="projects">Проекты</a>
  {% if is_auth %}<a href="logout">Выйти</a>{% else %}<a href="login">Войти</a>{% endif %}
</nav>

<h1>Загрузка статьи</h1>
<div id="dropzone">
    <div style="font-size: 3rem; margin-bottom: 15px; opacity: 0.8;">&#128190;</div>
    <div id="drop-info">Перетащите .md файлы сюда<br><span style="font-size: 0.8rem; opacity: 0.6;">или кликните для выбора</span></div>
</div>
<input type="file" id="fileInput" accept=".md" multiple style="display:none" />

<div style="width:100%; display:flex; flex-direction:column; align-items:center;">
    <button id="uploadBtn" class="action-btn" disabled>Опубликовать</button>
    <div id="progress-wrap"><div id="progress-fill"></div></div>
    <div class="message" id="message"></div>
</div>

<a href="main" class="nav-back">← Назад в блог</a>

<script>
  const dropzone = document.getElementById('dropzone');
  const fileInput = document.getElementById('fileInput');
  const uploadBtn = document.getElementById('uploadBtn');
  const message = document.getElementById('message');
  const pFill = document.getElementById('progress-fill');
  const pWrap = document.getElementById('progress-wrap');
  const dropInfo = document.getElementById('drop-info');

  const updateStatus = (text) => message.textContent = text;

  // Основная функция загрузки (вызывается мгновенно)
  function startUpload(files) {
    const selected = Array.from(files).filter(f => f.name.toLowerCase().endsWith('.md'));
    if(!selected.length) return updateStatus('❌ Выберите файлы .md');

    const formData = new FormData();
    selected.forEach(file => formData.append('files', file));

    const xhr = new XMLHttpRequest();
    xhr.open('POST', window.location.href, true);

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        pWrap.style.display = 'block';
        const percent = Math.round((e.loaded / e.total) * 100);
        pFill.style.width = percent + '%';
        updateStatus(`Загрузка: ${percent}%`);
      }
    };

    xhr.onload = () => {
      if (xhr.status === 200) {
        updateStatus('✅ Успешно опубликовано!');
        setTimeout(() => window.location.href = 'main', 1000);
      } else {
        resetUI('❌ Ошибка сервера');
      }
    };

    xhr.onerror = () => resetUI('❌ Ошибка сети');

    // Блокировка интерфейса
    uploadBtn.disabled = true;
    dropzone.classList.add('loading-ui');
    dropInfo.innerHTML = 'Передача данных...';

    xhr.send(formData);
  }

  // Привязка событий
  dropzone.onclick = () => fileInput.click();
  dropzone.ondragover = (e) => { e.preventDefault(); dropzone.classList.add('dragover'); };
  dropzone.ondragleave = () => dropzone.classList.remove('dragover');
  dropzone.ondrop = (e) => { 
    e.preventDefault(); 
    dropzone.classList.remove('dragover'); 
    if(e.dataTransfer.files.length) startUpload(e.dataTransfer.files); 
  };
  fileInput.onchange = () => { if(fileInput.files.length) startUpload(fileInput.files); };

  function resetUI(msg) {
    updateStatus(msg);
    uploadBtn.disabled = false;
    dropzone.classList.remove('loading-ui');
    dropInfo.innerHTML = 'Перетащите .md файлы сюда<br><span style="font-size: 0.8rem; opacity: 0.6;">или кликните для выбора</span>';
    pWrap.style.display = 'none';
    pFill.style.width = '0%';
  }
</script>
</body>
</html>

projects.html:

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
    <title>Мои Проекты</title>
    <style>
        :root { 
            --bg: #0a0a0a; 
            --text: #d1d5db; 
            --card: #121212; 
            --accent: #2196f3; 
            --transition: 0.3s; 
        }

        * { 
            box-sizing: border-box; 
            -webkit-tap-highlight-color: transparent; 
            font-family: 'Tahoma', sans-serif !important;
        }

        body { 
            background: var(--bg); color: var(--text); 
            margin: 0; display: flex; flex-direction: column; 
            align-items: center; min-height: 100vh;
            -webkit-font-smoothing: subpixel-antialiased;
        }

        /* Унифицированная навигация */
        nav {
            width: 100%; display: flex; justify-content: center; align-items: center;
            gap: 12px; padding: 35px 20px 25px; flex-wrap: wrap; 
        }
        nav a {
            color: inherit; text-decoration: none; font-weight: bold; font-size: 0.95rem;
            opacity: 0.6; transition: var(--transition); padding: 8px 14px; border-radius: 10px;
        }
        nav a:hover, nav a.active { 
            opacity: 1; 
            color: var(--accent); 
            background: rgba(33, 150, 243, 0.1); 
        }

        @media (max-width: 600px) {
            nav { 
                padding: 25px 15px 15px; 
                gap: 8px; 
                display: flex;
                flex-wrap: nowrap !important;
                justify-content: flex-start !important; 
                overflow-x: auto; 
                width: 100vw;
                -webkit-overflow-scrolling: touch;
                scrollbar-width: none; 
            }
            nav::-webkit-scrollbar { display: none; }
            nav a { padding: 8px 14px; font-size: 14px; flex-shrink: 0 !important; display: inline-block; }
        }

        h1 { 
            font-size: clamp(1.8rem, 8vw, 3rem); font-weight: bold; color: var(--accent); 
            margin: 10px 0 40px; text-transform: uppercase; letter-spacing: 2px; text-align: center; 
        }

        .grid {
            display: grid; 
            grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
            gap: 20px; width: 95%; max-width: 1100px; padding-bottom: 50px;
        }

        .card {
            background: var(--card); padding: 40px 30px; border-radius: 16px;
            border: 1px solid #222; transition: var(--transition); text-decoration: none; color: inherit;
            display: flex; flex-direction: column; align-items: center; text-align: center;
        }

        .card:hover { 
            border-color: var(--accent); transform: translateY(-5px);
            box-shadow: 0 12px 24px rgba(33, 150, 243, 0.15);
        }

        .card:hover .icon { transform: scale(1.15); }

        .card h3 { 
            color: #fff; margin: 20px 0 10px; font-size: 1.4rem; font-weight: bold; 
            letter-spacing: 0.5px;
        }

        .card p { 
            opacity: 0.5; font-size: 0.95rem; margin: 0; line-height: 1.5; 
            font-weight: normal; 
        }

        .icon { 
            font-size: 3rem; 
            transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); 
        }

        @media (max-width: 700px) {
            .grid { grid-template-columns: 1fr; width: 90%; }
            .card { padding: 30px 20px; }
        }
    </style>
</head>
<body>
    <nav>
        <a href="main">Блог</a>
        <a href="gallery">Галерея</a>
        <a href="projects" class="active">Проекты</a>
        {% if is_auth %}
            <a href="logout">Выйти</a>
        {% else %}
            <a href="login?next=projects">Войти</a>
        {% endif %}
    </nav>

    <h1>Мои сервисы</h1>

    <div class="grid">
        <a href="/blog/" class="card">
            <div class="icon">📝</div>
            <h3>Блог</h3>
            <p>Статьи, мысли и документация проекта</p>
        </a>
        <a href="/ytdl/" class="card" target="_blank">
            <div class="icon">🎬</div>
            <h3>YT Downloader</h3>
            <p>Удобная загрузка видео и аудио контента</p>
        </a>
        <a href="/editor/" class="card" target="_blank">
            <div class="icon">🛠️</div>
            <h3>HTML Editor</h3>
            <p>Мощный визуальный редактор кода</p>
        </a>
        <a href="/demo/" class="card" target="_blank">
            <div class="icon">✨</div>
            <h3>Demo</h3>
            <p>Демонстрация интерактивных веб-страниц</p>
        </a>
        <a href="/games/" class="card" target="_blank">
            <div class="icon">🕹️</div>
            <h3>Games</h3>
            <p>Развлекательный раздел для отдыха</p>
        </a>
    </div>
</body>
</html>